Skip to content

feat: add in-sandbox orchestrator binary with watch mode#85

Closed
robbycochran wants to merge 1 commit into
mainfrom
feat/orchestrator-binary
Closed

feat: add in-sandbox orchestrator binary with watch mode#85
robbycochran wants to merge 1 commit into
mainfrom
feat/orchestrator-binary

Conversation

@robbycochran

@robbycochran robbycochran commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Add a standalone Go binary (harness-orchestrator) that runs inside the OpenShell sandbox as the entrypoint, replacing the generated run.sh. Provides typed config, watch mode with cycle management, structured sentinel parsing (OPENSHELL_AGENT_RESULT protocol), and session history as append-only JSONL.

The binary sits between the OpenShell supervisor and the AI agent harness, managing the observe-decide-act-record loop with proper signal handling, heartbeats, retry with backoff, and lifecycle hooks.

New package: internal/orchestrator/ (6 source files, 25 tests) New binary: cmd/orchestrator/main.go (2.4MB, statically linked) Host-side: mode/sentinel/watch fields on AgentConfig, --mode/--watch flags on harness apply, orchestrator.yaml written to payload dir.

Backwards compatible — images without the binary fall back to run.sh.

Summary by CodeRabbit

  • New Features

    • Added a new orchestrator mode for running tasks in both one-shot and continuous watch workflows.
    • Introduced new CLI options to choose run mode and enable watch behavior.
    • Improved sandbox runs to use the orchestrator when available, with fallback behavior preserved.
    • Added support for multiple harness entrypoints and configurable execution behavior.
  • Bug Fixes

    • Fixed cleanup and packaging so generated runtime artifacts are handled more reliably during builds.

Add a standalone Go binary (harness-orchestrator) that runs inside the
OpenShell sandbox as the entrypoint, replacing the generated run.sh.
Provides typed config, watch mode with cycle management, structured
sentinel parsing (OPENSHELL_AGENT_RESULT protocol), and session history
as append-only JSONL.

This is the code-based alternative to the bash supervisor scripts in
NVIDIA/OpenShell#1826. The binary sits between the OpenShell supervisor
and the AI agent harness, managing the observe-decide-act-record loop
with proper signal handling, heartbeats, retry with backoff, and
lifecycle hooks.

New package: internal/orchestrator/ (6 source files, 25 tests)
New binary: cmd/orchestrator/main.go (2.4MB, statically linked)
Host-side: mode/sentinel/watch fields on AgentConfig, --mode/--watch
flags on harness apply, orchestrator.yaml written to payload dir.

Backwards compatible — images without the binary fall back to run.sh.
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a new harness-orchestrator binary and internal/orchestrator package implementing config loading, harness adapters (claude/codex/opencode), sentinel-based cycle result parsing, and JSONL session recording for once/watch agent execution. Wires this into AgentConfig YAML rendering, apply/executor CLI flags, Makefile build targets, and the sandbox Dockerfile.

Changes

Orchestrator Feature

Layer / File(s) Summary
Config schema and validation
internal/orchestrator/config.go, internal/orchestrator/config_test.go
OrchestratorConfig struct, LoadConfig, ApplyDefaults, and Validate are added with tests covering field parsing, defaults, and mode/entrypoint validation.
Sentinel protocol parsing
internal/orchestrator/sentinel.go, internal/orchestrator/sentinel_test.go
CycleResult, SentinelPrefix, ParseSentinel, SyntheticResult, and IsTerminal are added with tests for valid/malformed/multi-line sentinel output and exit-code mapping.
Harness adapters
internal/orchestrator/adapter.go, internal/orchestrator/adapter_test.go
HarnessAdapter interface, NewAdapter factory, and ClaudeAdapter/CodexAdapter/OpenCodeAdapter implementations are added with command-building tests.
Session recording
internal/orchestrator/session.go, internal/orchestrator/session_test.go
SessionRecord and SessionWriter (with NewSessionWriter, Write, Close) implement JSONL session persistence, tested for creation, writing, appending, and nested directories.
Orchestrator run loop
internal/orchestrator/orchestrator.go, internal/orchestrator/orchestrator_test.go
Orchestrator and New are added with Run dispatching once/watch modes, runCycle, resolveTask, sleepWithHeartbeat, runHooks, and logf, tested with mock adapters for success/failure/sentinel/watch/cancel scenarios.
CLI entrypoint
cmd/orchestrator/main.go
A new main package parses -config/-version flags, loads config, resolves task path, handles SIGINT/SIGTERM, and runs the Orchestrator.
AgentConfig orchestrator projection
internal/agent/agent.go
AgentConfig gains orchestrator fields, MergeOver overlays them, BuildOrchestratorConfig builds a config map, and RenderPayload writes orchestrator.yaml.
CLI and sandbox wiring
cmd/apply.go, cmd/executor.go
--mode/--watch flags override agentCfg.Mode; sandbox launch command conditionally runs harness-orchestrator, falling back to run.sh.
Build and image packaging
Makefile, profiles/images/sandbox-default/Dockerfile
orchestrator build target cross-compiles bin/harness-orchestrator, dev-sandbox/clean targets updated, and Dockerfile copies the binary into the sandbox image.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as cmd/apply or executor
  participant Orchestrator
  participant HarnessAdapter
  participant SessionWriter

  CLI->>Orchestrator: Run(ctx)
  loop until complete or cancelled
    Orchestrator->>HarnessAdapter: BuildCommand(task, headless)
    HarnessAdapter-->>Orchestrator: cmd output / exit code
    Orchestrator->>Orchestrator: ParseSentinel or SyntheticResult
    Orchestrator->>SessionWriter: Write(SessionRecord)
    Orchestrator->>Orchestrator: sleepWithHeartbeat, runHooks
  end
  Orchestrator-->>CLI: nil or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding an in-sandbox orchestrator binary with watch mode support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/orchestrator-binary

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/executor.go (1)

119-128: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

harness-orchestrator will reject entrypoint=bash, but apply.go advertises bash as a valid override.

cmd/apply.go documents --entrypoint as accepting claude, opencode, bash, and BuildOrchestratorConfig (internal/agent/agent.go) writes EffectiveEntrypoint() straight into orchestrator.yaml with no allow-list check. OrchestratorConfig.Validate() (internal/orchestrator/config.go) only accepts claude/codex/opencode and errors on anything else.

Since the Dockerfile change makes harness-orchestrator always present in the built image, this sh -c snippet will now always choose exec harness-orchestrator ... over the run.sh fallback (the fallback only triggers when the binary is absent, not when the config is incompatible). Any sandbox configured with entrypoint: bash will therefore fail to start: harness-orchestrator exits 1 on LoadConfig validation before ever running anything, with no fallback to run.sh.

Please gate the orchestrator path on entrypoint compatibility (e.g. only prefer harness-orchestrator when entrypoint is claude/codex/opencode), or extend the orchestrator's allow-list to support a "bash"/passthrough entrypoint.

🐛 Possible fix direction
-	} else {
-		sandboxCmd = []string{
-			"sh", "-c",
-			`if command -v harness-orchestrator >/dev/null 2>&1; then exec harness-orchestrator --config /sandbox/.config/openshell/orchestrator.yaml; else exec bash /sandbox/.config/openshell/run.sh; fi`,
-		}
-	}
+	} else if agentCfg.EffectiveEntrypoint() == "bash" {
+		sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"}
+	} else {
+		sandboxCmd = []string{
+			"sh", "-c",
+			`if command -v harness-orchestrator >/dev/null 2>&1; then exec harness-orchestrator --config /sandbox/.config/openshell/orchestrator.yaml; else exec bash /sandbox/.config/openshell/run.sh; fi`,
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/executor.go` around lines 119 - 128, The Sandbox command selection in
cmd/executor.go currently prefers harness-orchestrator whenever it exists, but
that path cannot start with entrypoint=bash because OrchestratorConfig.Validate
only allows claude, codex, and opencode. Update the sandboxCmd logic to check
entrypoint compatibility before choosing harness-orchestrator, so bash (and any
other unsupported entrypoint) falls back to run.sh instead of failing at
LoadConfig. If you want orchestrator to handle bash directly, then also extend
the allow-list in internal/orchestrator/config.go and keep
BuildOrchestratorConfig in internal/agent/agent.go aligned with that support.
♻️ Duplicate comments (1)
internal/orchestrator/adapter_test.go (1)

49-63: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tests encode the incorrect Codex CLI invocation.

These assertions ("codex --print review code", "codex -p review code") lock in the flags flagged as incorrect in adapter.go. Once the CodexAdapter.BuildCommand fix (use codex exec) lands, these expectations need to be updated accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/adapter_test.go` around lines 49 - 63, Update the Codex
adapter test expectations so they match the corrected CLI built by
CodexAdapter.BuildCommand; the current assertions in TestCodexAdapterCommands
still hard-code the old print flags. Adjust the expected command strings to
reflect the new codex exec invocation for both headless and tty cases, using the
BuildCommand and CodexAdapter symbols to verify the updated args.
🧹 Nitpick comments (3)
internal/orchestrator/config.go (1)

61-73: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Validate doesn't bound-check numeric fields.

Only Mode and Entrypoint are validated; PollInterval, MaxFailures, and Heartbeat can carry negative or absurd values from YAML and still pass validation (compounded by the ApplyDefaults gap above). Consider validating these are positive after defaults are applied.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/config.go` around lines 61 - 73, Validate the numeric
fields in OrchestratorConfig.Validate in addition to Mode and Entrypoint: add
bounds checks for PollInterval, MaxFailures, and Heartbeat so negative or
otherwise invalid values are rejected after defaults are applied. Update
Validate to return an error when these fields are non-positive or out of range,
keeping the existing checks for Mode and Entrypoint intact.
internal/orchestrator/orchestrator_test.go (1)

99-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for mid-cycle cancellation and terminal-failure status.

Existing watch tests only exercise ctx cancellation during the sleep phase (mockAdapter's script finishes instantly via echo), and only exercise "complete" as a terminal status. Consider adding:

  • A case where the mock command sleeps for a while so a context cancellation must interrupt an in-flight cmd.Run().
  • A case asserting Run() returns an error when the sentinel reports "terminal_failure" (matching multiMockAdapter's fallback fixture, which currently isn't asserted against).

This would lock in the fixes proposed for internal/orchestrator/orchestrator.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/orchestrator_test.go` around lines 99 - 170, The watch
tests in TestRunWatchStopsOnComplete and TestRunWatchContextCancel only cover
cancellation during the sleep interval and do not verify terminal-failure
handling. Add a test around Orchestrator.Run that uses the mockAdapter path with
a long-running command so context cancellation interrupts an in-flight
cmd.Run(), and add another assertion using multiMockAdapter’s terminal-failure
sentinel to confirm Run() returns an error for "terminal_failure". Keep the
coverage focused on the watch flow and the status handling in Run, without
relying on the instant-echo behavior.
Makefile (1)

99-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Error suppression on cp can mask failures that the mandatory Dockerfile COPY can't tolerate.

dev-sandbox depends on orchestrator, so by the time cp runs, bin/harness-orchestrator should reliably exist — the 2>/dev/null || true mainly hides real failures (e.g. permission issues writing into profiles/images/sandbox-default/). Since the Dockerfile's COPY harness-orchestrator ... is unconditional, a masked cp failure surfaces later as a confusing "file not found" error from docker build rather than a clear message at the cp step.

♻️ Proposed fix
-	cp bin/harness-orchestrator profiles/images/sandbox-default/harness-orchestrator 2>/dev/null || true
+	cp bin/harness-orchestrator profiles/images/sandbox-default/harness-orchestrator
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 99 - 104, The dev-sandbox target currently masks cp
failures with 2>/dev/null || true, which can hide real write or permission
issues before docker build. Update the dev-sandbox recipe to let cp fail visibly
and surface the problem at the copy step, using the dev-sandbox and orchestrator
target flow to locate it. Keep the binary copy into
profiles/images/sandbox-default/ explicit so any missing
bin/harness-orchestrator or destination write failure is reported immediately
rather than later by the Dockerfile COPY.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/apply.go`:
- Around line 55-60: The mode selection in the apply command is bypassing the
standard flag resolution and allowing conflicting inputs to silently override
each other. Update the apply flow around mode/watch handling to use the same
Explicit flag > OPENSHELL_* env var > config file > default pattern used by
gatewayName, and validate the resolved mode in the apply command itself before
assigning it to agentCfg.Mode. Also, when both the mode flag and watch flag are
provided, detect the conflict explicitly and return an error instead of letting
watch override the user’s chosen mode.

In `@cmd/orchestrator/main.go`:
- Around line 38-50: Treat SIGINT/SIGTERM as a clean shutdown at the CLI
boundary by updating cmd/orchestrator/main.go so the error returned from
orch.Run(ctx) is mapped to nil when it is context.Canceled; otherwise keep the
existing non-zero exit path. Use the ctx from signal.NotifyContext and the
orch.Run entrypoint to locate the change, and ensure only real failures still
print an error and exit 1 while user-initiated cancellation exits successfully.

In `@internal/orchestrator/adapter.go`:
- Around line 40-52: CodexAdapter.BuildCommand still uses unsupported flags for
task execution, so switch the task-run path to the documented codex exec
subcommand instead of passing --print or -p. Update BuildCommand in CodexAdapter
so non-empty tasks are invoked via codex exec with the prompt argument, while
preserving the empty-task behavior if needed.

In `@internal/orchestrator/config.go`:
- Around line 40-59: The defaulting logic in OrchestratorConfig.ApplyDefaults is
inconsistent because Heartbeat only resets on 0 while PollInterval and
MaxFailures also handle negative values. Update ApplyDefaults to treat Heartbeat
the same way as the other numeric fields so malformed negative values are
replaced with the default, and keep the behavior aligned with Validate and
downstream timer use such as sleepWithHeartbeat.

In `@internal/orchestrator/orchestrator.go`:
- Around line 206-222: The runHooks method blocks the watch loop because it
executes each hook synchronously with cmd.Run() and no timeout or cancellation
path. Update Orchestrator.runHooks to accept and use a context or derive a
timeout when creating the exec.Command, so hung hooks can be interrupted instead
of blocking indefinitely. Keep the existing hook selection logic for
result.Status and preserve logging in logf, but ensure each hook execution is
bounded and can honor shutdown/cancellation signals.
- Around line 38-106: Update Orchestrator.Run and runWatch so terminal failures
are surfaced as errors instead of success. In runWatch, keep treating
IsTerminal() as a stop condition, but return an error when result.Status is
"terminal_failure" (using result.Reason for context) and only return nil for
successful terminal states. In the "once" branch of Run, also check
result.Status alongside ExitCode so a sentinel terminal_failure with exit code 0
is rejected; use the existing runCycle, IsTerminal, and result.Status/Reason
fields to locate the logic.
- Around line 108-167: runCycle ignores ctx while launching the agent
subprocess, so cancellation and SIGTERM/Ctrl+C won’t interrupt an active cycle.
Update Orchestrator.runCycle to thread ctx into the command creation path,
ideally through adapter.BuildCommand, so cmd.Run() is context-aware. If
BuildCommand cannot accept ctx, add explicit handling for ctx.Done() that
terminates the subprocess (or its process group) during the active run. Keep the
existing sentinel parsing and SessionRecord flow unchanged.

In `@Makefile`:
- Around line 37-41: The orchestrator build target is hardcoding a linux/amd64
binary, which breaks native-arch sandbox builds on arm64 hosts. Update the
Makefile’s orchestrator target so it builds for the host architecture instead of
forcing GOARCH=amd64, and keep the output path/name used by dev-sandbox and
executor.go unchanged so the existing command lookup still works. Use the
orchestrator target as the fix point and align it with the native-arch behavior
implied by the Dockerfile and dev-sandbox flow.

---

Outside diff comments:
In `@cmd/executor.go`:
- Around line 119-128: The Sandbox command selection in cmd/executor.go
currently prefers harness-orchestrator whenever it exists, but that path cannot
start with entrypoint=bash because OrchestratorConfig.Validate only allows
claude, codex, and opencode. Update the sandboxCmd logic to check entrypoint
compatibility before choosing harness-orchestrator, so bash (and any other
unsupported entrypoint) falls back to run.sh instead of failing at LoadConfig.
If you want orchestrator to handle bash directly, then also extend the
allow-list in internal/orchestrator/config.go and keep BuildOrchestratorConfig
in internal/agent/agent.go aligned with that support.

---

Duplicate comments:
In `@internal/orchestrator/adapter_test.go`:
- Around line 49-63: Update the Codex adapter test expectations so they match
the corrected CLI built by CodexAdapter.BuildCommand; the current assertions in
TestCodexAdapterCommands still hard-code the old print flags. Adjust the
expected command strings to reflect the new codex exec invocation for both
headless and tty cases, using the BuildCommand and CodexAdapter symbols to
verify the updated args.

---

Nitpick comments:
In `@internal/orchestrator/config.go`:
- Around line 61-73: Validate the numeric fields in OrchestratorConfig.Validate
in addition to Mode and Entrypoint: add bounds checks for PollInterval,
MaxFailures, and Heartbeat so negative or otherwise invalid values are rejected
after defaults are applied. Update Validate to return an error when these fields
are non-positive or out of range, keeping the existing checks for Mode and
Entrypoint intact.

In `@internal/orchestrator/orchestrator_test.go`:
- Around line 99-170: The watch tests in TestRunWatchStopsOnComplete and
TestRunWatchContextCancel only cover cancellation during the sleep interval and
do not verify terminal-failure handling. Add a test around Orchestrator.Run that
uses the mockAdapter path with a long-running command so context cancellation
interrupts an in-flight cmd.Run(), and add another assertion using
multiMockAdapter’s terminal-failure sentinel to confirm Run() returns an error
for "terminal_failure". Keep the coverage focused on the watch flow and the
status handling in Run, without relying on the instant-echo behavior.

In `@Makefile`:
- Around line 99-104: The dev-sandbox target currently masks cp failures with
2>/dev/null || true, which can hide real write or permission issues before
docker build. Update the dev-sandbox recipe to let cp fail visibly and surface
the problem at the copy step, using the dev-sandbox and orchestrator target flow
to locate it. Keep the binary copy into profiles/images/sandbox-default/
explicit so any missing bin/harness-orchestrator or destination write failure is
reported immediately rather than later by the Dockerfile COPY.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3d89c83f-2679-4311-976c-d7c513ddd390

📥 Commits

Reviewing files that changed from the base of the PR and between ac8bccf and 2eae9f9.

📒 Files selected for processing (16)
  • Makefile
  • cmd/apply.go
  • cmd/executor.go
  • cmd/orchestrator/main.go
  • internal/agent/agent.go
  • internal/orchestrator/adapter.go
  • internal/orchestrator/adapter_test.go
  • internal/orchestrator/config.go
  • internal/orchestrator/config_test.go
  • internal/orchestrator/orchestrator.go
  • internal/orchestrator/orchestrator_test.go
  • internal/orchestrator/sentinel.go
  • internal/orchestrator/sentinel_test.go
  • internal/orchestrator/session.go
  • internal/orchestrator/session_test.go
  • profiles/images/sandbox-default/Dockerfile

Comment thread cmd/apply.go
Comment on lines +55 to +60
if watch {
mode = "watch"
}
if mode != "" {
agentCfg.Mode = mode
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

--mode/--watch skip the env-var tier and silently override conflicting input.

Two issues here:

  1. Other flags in this file (e.g. gatewayName at line 149) resolve via envOr("OPENSHELL_GATEWAY", ""), giving the order Explicit flag > OPENSHELL_* env var > config file > default. The new mode/watch flags default to ""/false with no env-var fallback, breaking that contract.
  2. If a user passes --mode once --watch together, watch silently overrides the explicit --mode value with no error, and an invalid --mode value (anything other than once/watch) is not validated here — it will only fail much later inside the sandbox when harness-orchestrator calls Validate().

As per coding guidelines, "Use flag resolution order: Explicit flag > OPENSHELL_* env var > config file > default."

🔧 Proposed fix
-	cmd.Flags().StringVar(&mode, "mode", "", "Orchestrator mode: once or watch")
+	cmd.Flags().StringVar(&mode, "mode", envOr("OPENSHELL_MODE", ""), "Orchestrator mode: once or watch")
 	cmd.Flags().BoolVar(&watch, "watch", false, "Shorthand for --mode=watch")
 			if watch {
-				mode = "watch"
+				if mode != "" && mode != "watch" {
+					return fmt.Errorf("--mode=%s conflicts with --watch", mode)
+				}
+				mode = "watch"
 			}
 			if mode != "" {
+				if mode != "once" && mode != "watch" {
+					return fmt.Errorf("--mode must be \"once\" or \"watch\", got %q", mode)
+				}
 				agentCfg.Mode = mode
 			}

Also applies to: 154-155

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/apply.go` around lines 55 - 60, The mode selection in the apply command
is bypassing the standard flag resolution and allowing conflicting inputs to
silently override each other. Update the apply flow around mode/watch handling
to use the same Explicit flag > OPENSHELL_* env var > config file > default
pattern used by gatewayName, and validate the resolved mode in the apply command
itself before assigning it to agentCfg.Mode. Also, when both the mode flag and
watch flag are provided, detect the conflict explicitly and return an error
instead of letting watch override the user’s chosen mode.

Source: Coding guidelines

Comment thread cmd/orchestrator/main.go
Comment on lines +38 to +50
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

orch, err := orchestrator.New(cfg, configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err)
os.Exit(1)
}

if err := orch.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err)
os.Exit(1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep run --pattern 'func (o *Orchestrator) runWatch($_) $_' --lang go internal/orchestrator/orchestrator.go
rg -n -A20 'func \(o \*Orchestrator\) runWatch' internal/orchestrator/orchestrator.go

Repository: stackrox/harness-openshell

Length of output: 4110


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the orchestrator entrypoint and Run implementation.
grep -n -A40 -B10 'func (o \*Orchestrator) Run' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,180p' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,120p' cmd/orchestrator/main.go

Repository: stackrox/harness-openshell

Length of output: 6619


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether any code already treats context cancellation specially at the CLI boundary.
rg -n 'context\.Canceled|context\.DeadlineExceeded|errors\.Is\(.*ctx|signal\.NotifyContext|os\.Exit\(1\)' cmd internal

Repository: stackrox/harness-openshell

Length of output: 407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Run a small read-only probe to show the exact error propagated by runWatch on cancellation.
python3 - <<'PY'
from pathlib import Path
p = Path('internal/orchestrator/orchestrator.go')
text = p.read_text()
for needle in [
    'func (o *Orchestrator) Run',
    'func (o *Orchestrator) runWatch',
    'if ctx.Err() != nil {',
    'return ctx.Err()',
]:
    print(f'== {needle} ==')
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            start = max(1, i-3)
            end = min(len(text.splitlines()), i+6)
            for j in range(start, end+1):
                print(f'{j}:{text.splitlines()[j-1]}')
            break
PY

Repository: stackrox/harness-openshell

Length of output: 1456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant CLI exit path in main.go with line numbers.
nl -ba cmd/orchestrator/main.go | sed -n '1,120p'

Repository: stackrox/harness-openshell

Length of output: 204


Treat SIGINT/SIGTERM as a clean shutdown.

runWatch returns ctx.Err() on cancellation, and main exits 1 on any non-nil error. Map context.Canceled to nil at the CLI boundary so a requested stop exits 0 instead of looking like a failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/orchestrator/main.go` around lines 38 - 50, Treat SIGINT/SIGTERM as a
clean shutdown at the CLI boundary by updating cmd/orchestrator/main.go so the
error returned from orch.Run(ctx) is mapped to nil when it is context.Canceled;
otherwise keep the existing non-zero exit path. Use the ctx from
signal.NotifyContext and the orch.Run entrypoint to locate the change, and
ensure only real failures still print an error and exit 1 while user-initiated
cancellation exits successfully.

Comment on lines +40 to +52
type CodexAdapter struct{}

func (CodexAdapter) Name() string { return "codex" }

func (CodexAdapter) BuildCommand(task string, headless bool) *exec.Cmd {
if task == "" {
return exec.Command("codex")
}
if headless {
return exec.Command("codex", "--print", task)
}
return exec.Command("codex", "-p", task)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Files mentioning codex / adapter\n'
git ls-files | rg 'internal/orchestrator/adapter\.go|codex|orchestrator' || true

printf '\n## Search for codex references\n'
rg -n --hidden --glob '!**/.git/**' '\bcodex\b|--print|\-p\b|exec subcommand|codex exec|codex e' .

printf '\n## Read target file with line numbers\n'
sed -n '1,140p' internal/orchestrator/adapter.go

Repository: stackrox/harness-openshell

Length of output: 5751


🌐 Web query:

OpenAI Codex CLI documentation exec subcommand --print -p non-interactive prompt flags

💡 Result:

The OpenAI Codex CLI (a Rust-based coding agent) uses the exec subcommand to run tasks non-interactively, which is designed for automation, CI/CD pipelines, and scripting [1][2][3]. Key flags and usage patterns for codex exec: Basic Syntax codex exec [OPTIONS] [PROMPT] [3] Core Flags and Options - --json: Enables structured output by streaming newline-delimited JSON events instead of standard formatted text, which is ideal for programmatic processing (e.g., using jq) [1][3]. - --last: Used with the resume subcommand to continue the most recent session from the current working directory [1][4]. - --all: Used with the resume subcommand to search for sessions across all directories [1][4]. - --model (or -m): Specifies the AI model to use for the task [3][5]. - --sandbox: Configures permission levels for the environment, such as workspace-write (allow edits) or danger-full-access (broad access) [2]. Non-interactive Prompt Handling The exec command handles prompts in several ways: - Inline Prompt: Pass the prompt directly as an argument, e.g., codex exec "explain this codebase" [3]. - Stdin: If no prompt is provided, Codex reads from stdin [2][3]. You can force this behavior by explicitly using codex exec - [2][3]. - Input Pipes: You can pipe content into the command, for example, echo "add error handling" | codex exec [3]. Resume Subcommand If a non-interactive task needs to be continued, you can use the resume subcommand [1][3]: codex exec resume [SESSION_ID] [1][4] codex exec resume --last "follow-up prompt" [2][6] For comprehensive documentation, you can refer to the official OpenAI Codex CLI reference [1][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' internal/orchestrator/adapter_test.go
printf '\n---\n'
sed -n '1,220p' internal/orchestrator/orchestrator.go

Repository: stackrox/harness-openshell

Length of output: 7065


internal/orchestrator/adapter.go:46-51 — Use codex exec for task runs CodexAdapter still passes unsupported --print/-p flags to codex. The documented prompt entrypoint is codex exec [PROMPT], so any tasked run will fail until this adapter switches to that subcommand.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/adapter.go` around lines 40 - 52,
CodexAdapter.BuildCommand still uses unsupported flags for task execution, so
switch the task-run path to the documented codex exec subcommand instead of
passing --print or -p. Update BuildCommand in CodexAdapter so non-empty tasks
are invoked via codex exec with the prompt argument, while preserving the
empty-task behavior if needed.

Comment on lines +40 to +59
func (c *OrchestratorConfig) ApplyDefaults() {
if c.Mode == "" {
c.Mode = "once"
}
if c.Entrypoint == "" {
c.Entrypoint = "claude"
}
if c.PollInterval <= 0 {
c.PollInterval = 300
}
if c.MaxFailures <= 0 {
c.MaxFailures = 5
}
if c.Heartbeat == 0 {
c.Heartbeat = 60
}
if c.SessionDir == "" {
c.SessionDir = "/sandbox/.harness"
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Inconsistent zero/negative guard for Heartbeat vs other fields.

PollInterval and MaxFailures use <= 0 so negative values fall back to defaults, but Heartbeat uses == 0 (line 53), letting a negative value pass through unmodified. Validate() (lines 61-73) never checks numeric ranges either, so a negative Heartbeat from a malformed orchestrator.yaml reaches the orchestrator unchecked, which is likely to break the heartbeat/sleep timer downstream (e.g., a negative duration in sleepWithHeartbeat).

🐛 Proposed fix
-	if c.Heartbeat == 0 {
+	if c.Heartbeat <= 0 {
 		c.Heartbeat = 60
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (c *OrchestratorConfig) ApplyDefaults() {
if c.Mode == "" {
c.Mode = "once"
}
if c.Entrypoint == "" {
c.Entrypoint = "claude"
}
if c.PollInterval <= 0 {
c.PollInterval = 300
}
if c.MaxFailures <= 0 {
c.MaxFailures = 5
}
if c.Heartbeat == 0 {
c.Heartbeat = 60
}
if c.SessionDir == "" {
c.SessionDir = "/sandbox/.harness"
}
}
func (c *OrchestratorConfig) ApplyDefaults() {
if c.Mode == "" {
c.Mode = "once"
}
if c.Entrypoint == "" {
c.Entrypoint = "claude"
}
if c.PollInterval <= 0 {
c.PollInterval = 300
}
if c.MaxFailures <= 0 {
c.MaxFailures = 5
}
if c.Heartbeat <= 0 {
c.Heartbeat = 60
}
if c.SessionDir == "" {
c.SessionDir = "/sandbox/.harness"
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/config.go` around lines 40 - 59, The defaulting logic
in OrchestratorConfig.ApplyDefaults is inconsistent because Heartbeat only
resets on 0 while PollInterval and MaxFailures also handle negative values.
Update ApplyDefaults to treat Heartbeat the same way as the other numeric fields
so malformed negative values are replaced with the default, and keep the
behavior aligned with Validate and downstream timer use such as
sleepWithHeartbeat.

Comment on lines +38 to +106
func (o *Orchestrator) Run(ctx context.Context) error {
defer o.session.Close()

switch o.config.Mode {
case "once":
result, err := o.runCycle(ctx)
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("agent exited with code %d", result.ExitCode)
}
return nil
case "watch":
return o.runWatch(ctx)
default:
return fmt.Errorf("unknown mode %q", o.config.Mode)
}
}

func (o *Orchestrator) runWatch(ctx context.Context) error {
for {
result, err := o.runCycle(ctx)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
o.failures++
logf("cycle %d failed: %v (failure %d/%d)", o.cycle, err, o.failures, o.config.MaxFailures)
if o.failures >= o.config.MaxFailures {
return fmt.Errorf("max transient failures (%d) exceeded", o.config.MaxFailures)
}
o.sleepWithHeartbeat(ctx, o.config.PollInterval)
if ctx.Err() != nil {
return ctx.Err()
}
continue
}

o.runHooks(result)

if result.IsTerminal() {
logf("cycle %d: terminal status %q (%s)", o.cycle, result.Status, result.Reason)
return nil
}

switch result.Status {
case "transient_failure":
o.failures++
logf("cycle %d: transient failure (%s), failure %d/%d", o.cycle, result.Reason, o.failures, o.config.MaxFailures)
if o.failures >= o.config.MaxFailures {
return fmt.Errorf("max transient failures (%d) exceeded", o.config.MaxFailures)
}
default:
o.failures = 0
}

poll := o.config.PollInterval
if result.PollSeconds > 0 {
poll = result.PollSeconds
}

logf("cycle %d: %s (%s), next cycle in %ds", o.cycle, result.Status, result.Reason, poll)
o.sleepWithHeartbeat(ctx, poll)
if ctx.Err() != nil {
return ctx.Err()
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify which statuses IsTerminal() treats as terminal, and whether failure statuses exist
ast-grep run --pattern 'func (r *CycleResult) IsTerminal() $_ { $$$ }' --lang go internal/orchestrator/sentinel.go
rg -n -A5 'func.*IsTerminal' internal/orchestrator/sentinel.go
rg -n '"terminal_failure"|"transient_failure"' internal/orchestrator

Repository: stackrox/harness-openshell

Length of output: 1496


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' internal/orchestrator/sentinel.go
printf '\n--- orchestrator.go ---\n'
sed -n '1,220p' internal/orchestrator/orchestrator.go
printf '\n--- orchestrator_test excerpt ---\n'
sed -n '160,240p' internal/orchestrator/orchestrator_test.go

Repository: stackrox/harness-openshell

Length of output: 8039


Propagate terminal failures from the orchestrator

  • runWatch returns nil for every IsTerminal() result, including terminal_failure, so unrecoverable agent failures exit as success.
  • Run(..., "once") only checks ExitCode; a sentinel terminal_failure with exit code 0 also passes as success.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/orchestrator.go` around lines 38 - 106, Update
Orchestrator.Run and runWatch so terminal failures are surfaced as errors
instead of success. In runWatch, keep treating IsTerminal() as a stop condition,
but return an error when result.Status is "terminal_failure" (using
result.Reason for context) and only return nil for successful terminal states.
In the "once" branch of Run, also check result.Status alongside ExitCode so a
sentinel terminal_failure with exit code 0 is rejected; use the existing
runCycle, IsTerminal, and result.Status/Reason fields to locate the logic.

Source: Path instructions

Comment on lines +108 to +167
func (o *Orchestrator) runCycle(ctx context.Context) (*CycleResult, error) {
o.cycle++
start := time.Now()

task := o.resolveTask()
headless := !o.config.TTY
cmd := o.adapter.BuildCommand(task, headless)
cmd.Stderr = os.Stderr

var stdout bytes.Buffer
if headless {
cmd.Stdout = &stdout
} else {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
}

logf("cycle %d: running %s", o.cycle, o.adapter.Name())
err := cmd.Run()
duration := time.Since(start)

var result *CycleResult
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return nil, fmt.Errorf("running %s: %w", o.adapter.Name(), err)
}
}

if o.config.Sentinel && headless {
parsed, parseErr := ParseSentinel(stdout.Bytes())
if parseErr != nil {
logf("cycle %d: sentinel parse error: %v", o.cycle, parseErr)
result = &CycleResult{Status: "transient_failure", Reason: "malformed_sentinel", ExitCode: exitCode}
} else if parsed != nil {
result = parsed
result.ExitCode = exitCode
}
}

if result == nil {
result = SyntheticResult(exitCode)
}

record := SessionRecord{
Timestamp: start,
Cycle: o.cycle,
Entrypoint: o.adapter.Name(),
Mode: o.config.Mode,
DurationSec: duration.Seconds(),
Result: result,
}
if writeErr := o.session.Write(record); writeErr != nil {
logf("warning: failed to write session record: %v", writeErr)
}

return result, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm HarnessAdapter.BuildCommand signature and whether ctx is threaded anywhere into exec.Cmd construction
rg -n -A10 'type HarnessAdapter interface' internal/orchestrator/adapter.go
rg -n 'CommandContext|cmd.Process.Kill|cmd.Process.Signal' internal/orchestrator

Repository: stackrox/harness-openshell

Length of output: 457


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== orchestrator outline =="
ast-grep outline internal/orchestrator/orchestrator.go --view expanded || true

echo "== adapter outline =="
ast-grep outline internal/orchestrator/adapter.go --view expanded || true

echo "== search for context-aware process handling =="
rg -n 'CommandContext|Process.Kill|Process.Signal|ctx.Done|WithContext|exec\.Command\(' internal/orchestrator internal -g '!**/*_test.go' || true

echo "== relevant slices =="
sed -n '1,220p' internal/orchestrator/orchestrator.go
printf '\n--- adapter.go ---\n'
sed -n '1,220p' internal/orchestrator/adapter.go

Repository: stackrox/harness-openshell

Length of output: 10148


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files internal/orchestrator

echo "== line counts =="
wc -l internal/orchestrator/orchestrator.go internal/orchestrator/adapter.go

echo "== adapter implementations =="
rg -n 'func .*BuildCommand|type .*Adapter struct' internal/orchestrator -g '!**/*_test.go' || true

Repository: stackrox/harness-openshell

Length of output: 1209


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('internal/orchestrator/orchestrator.go')
text = p.read_text()
for needle in ['ctx context.Context', 'cmd.Run()', 'ParseSentinel', 'sleepWithHeartbeat']:
    print(f'-- {needle} --')
    for i, line in enumerate(text.splitlines(), 1):
        if needle in line:
            start = max(1, i-8)
            end = min(len(text.splitlines()), i+12)
            for j in range(start, end+1):
                print(f'{j}:{text.splitlines()[j-1]}')
            print()
            break
PY

Repository: stackrox/harness-openshell

Length of output: 2558


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,220p' internal/orchestrator/adapter.go
printf '\n---\n'
rg -n 'func .*BuildCommand' internal/orchestrator -g '!**/*_test.go' || true

Repository: stackrox/harness-openshell

Length of output: 6806


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -A12 -B4 'func \(.*\) BuildCommand\(task string, headless bool\) \*exec\.Cmd' internal/orchestrator -g '!**/*_test.go' || true
rg -n 'exec\.CommandContext|exec\.Command\(' internal/orchestrator -g '!**/*_test.go' || true

Repository: stackrox/harness-openshell

Length of output: 3134


Wire ctx into the agent subprocess
runCycle accepts ctx, but the command is still created with exec.Command, so cancellation only takes effect between cycles. A SIGTERM/Ctrl+C during an active run won’t stop cmd.Run(); pass ctx into BuildCommand or kill the process group on ctx.Done().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/orchestrator.go` around lines 108 - 167, runCycle
ignores ctx while launching the agent subprocess, so cancellation and
SIGTERM/Ctrl+C won’t interrupt an active cycle. Update Orchestrator.runCycle to
thread ctx into the command creation path, ideally through adapter.BuildCommand,
so cmd.Run() is context-aware. If BuildCommand cannot accept ctx, add explicit
handling for ctx.Done() that terminates the subprocess (or its process group)
during the active run. Keep the existing sentinel parsing and SessionRecord flow
unchanged.

Source: Path instructions

Comment on lines +206 to +222
func (o *Orchestrator) runHooks(result *CycleResult) {
var hooks []string
switch result.Status {
case "complete":
hooks = o.config.OnComplete
case "propose":
hooks = o.config.OnPropose
}
for _, h := range hooks {
cmd := exec.Command("sh", "-c", h)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
logf("hook failed: %v", err)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Hooks run with no timeout, and shell out via sh -c.

runHooks runs cmd.Run() synchronously with no timeout (line 218); if a hook script hangs (e.g. a network call), the watch loop is blocked indefinitely with no way to recover via MaxFailures or ctx cancellation, since ctx isn't passed in here either. Static analysis also flags exec.Command("sh", "-c", h) for CWE-78 — h comes from operator-authored OnComplete/OnPropose config rather than agent/runtime output, so injection risk is low in practice, but bounding execution time would still close a real reliability gap.

🐛 Suggested fix
-func (o *Orchestrator) runHooks(result *CycleResult) {
+func (o *Orchestrator) runHooks(ctx context.Context, result *CycleResult) {
 	var hooks []string
 	switch result.Status {
 	case "complete":
 		hooks = o.config.OnComplete
 	case "propose":
 		hooks = o.config.OnPropose
 	}
 	for _, h := range hooks {
-		cmd := exec.Command("sh", "-c", h)
+		hookCtx, cancel := context.WithTimeout(ctx, o.config.HookTimeout)
+		cmd := exec.CommandContext(hookCtx, "sh", "-c", h)
 		cmd.Stdout = os.Stdout
 		cmd.Stderr = os.Stderr
 		if err := cmd.Run(); err != nil {
 			logf("hook failed: %v", err)
 		}
+		cancel()
 	}
 }
🧰 Tools
🪛 ast-grep (0.44.0)

[error] 214-214: A shell (sh/bash) is invoked with -c and a dynamically built command string (string concatenation, fmt.Sprintf, or a variable holding the command) passed to exec.Command / exec.CommandContext. Untrusted input embedded in the command lets an attacker inject arbitrary shell commands. Avoid the shell: call the target binary directly with exec.Command(name, arg1, arg2, ...) so each argument is passed as a separate, non-interpreted token, and never build a shell command string from external input.
Context: exec.Command("sh", "-c", h)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(command-injection-exec-sh-c-go)

🪛 OpenGrep (1.23.0)

[ERROR] 215-215: Dynamic command passed to exec.Command with a shell invocation. Pass arguments directly to exec.Command without a shell wrapper.

(coderabbit.command-injection.go-exec-command)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/orchestrator/orchestrator.go` around lines 206 - 222, The runHooks
method blocks the watch loop because it executes each hook synchronously with
cmd.Run() and no timeout or cancellation path. Update Orchestrator.runHooks to
accept and use a context or derive a timeout when creating the exec.Command, so
hung hooks can be interrupted instead of blocking indefinitely. Keep the
existing hook selection logic for result.Status and preserve logging in logf,
but ensure each hook execution is bounded and can honor shutdown/cancellation
signals.

Source: Path instructions

Comment thread Makefile
Comment on lines +37 to +41
## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
@echo "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

orchestrator target hardcodes GOARCH=amd64, conflicting with native-arch dev builds.

dev-sandbox is documented as "native arch only" (line 98) and the Dockerfile downloads arch-specific binaries via uname -m (e.g. lines 52-54), implying the sandbox image is built for the host's native architecture. But this target unconditionally cross-compiles harness-orchestrator for linux/amd64. On an arm64 dev host (e.g. Apple Silicon), make dev-sandbox will bake an amd64 binary into an arm64 image, causing an exec-format failure at container runtime — and since executor.go only checks command -v harness-orchestrator (existence, not architecture), there is no fallback to run.sh in that case.

🔧 Proposed fix
+ORCH_GOARCH ?= $(shell go env GOARCH)
+
 ## Build the in-sandbox orchestrator binary (linux/amd64)
 orchestrator:
-	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
+	CGO_ENABLED=0 GOOS=linux GOARCH=$(ORCH_GOARCH) go build -ldflags '$(LDFLAGS)' \
 		-o bin/harness-orchestrator ./cmd/orchestrator
-	`@echo` "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"
+	`@echo` "Built: bin/harness-orchestrator (linux/$(ORCH_GOARCH), $(VERSION))"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
@echo "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"
ORCH_GOARCH ?= $(shell go env GOARCH)
## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
CGO_ENABLED=0 GOOS=linux GOARCH=$(ORCH_GOARCH) go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
`@echo` "Built: bin/harness-orchestrator (linux/$(ORCH_GOARCH), $(VERSION))"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 37 - 41, The orchestrator build target is hardcoding a
linux/amd64 binary, which breaks native-arch sandbox builds on arm64 hosts.
Update the Makefile’s orchestrator target so it builds for the host architecture
instead of forcing GOARCH=amd64, and keep the output path/name used by
dev-sandbox and executor.go unchanged so the existing command lookup still
works. Use the orchestrator target as the fix point and align it with the
native-arch behavior implied by the Dockerfile and dev-sandbox flow.

@robbycochran robbycochran deleted the feat/orchestrator-binary branch July 1, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant